Prisma 트랜잭션의 두 가지 사용 방식
Prisma 트랜잭션의 두 가지 사용 방식
서로의 결과를 사용하지 않는 Prisma 쿼리는 $transaction([...])으로 간결하게 묶을 수 있다. 중간 조회 결과에 따라 분기하거나 계산해야 한다면 interactive transaction을 사용한다. 다만 트랜잭션 문법을 선택하는 것보다 먼저, nested write나 원자적 갱신으로 더 짧게 표현할 수 없는지 확인하는 편이 좋다.
목차
- #트랜잭션이 필요한 범위부터 정한다
- #Prisma가 제공하는 선택지를 한눈에 보기
- #배열형 트랜잭션은 독립 작업에 적합하다
- #Interactive transaction은 읽고 판단한 뒤 쓸 때 사용한다
- #트랜잭션 콜백 안에서 피해야 할 작업
- #격리 수준은 동시성 문제의 종류에 맞춘다
- #Serializable 충돌은 정상적인 실패 경로다
- #트랜잭션을 쓰지 않고도 원자성을 얻는 방법
- #테스트와 관측 가능성
- #선택 체크리스트
- #정리
- #관련 노트와 참고 자료
트랜잭션이 필요한 범위부터 정한다
온라인 상점에서 주문을 확정할 때 다음 변경이 필요하다고 해 보자.
- 재고를 차감한다.
- 주문 상태를
PENDING에서CONFIRMED로 바꾼다. - 결제 원장을 기록한다.
재고만 줄고 주문은 그대로이거나, 주문은 확정됐는데 원장이 없다면 데이터가 서로 모순된다. 이 세 변경이 하나의 업무 불변식을 구성한다면 전부 성공하거나 전부 롤백되어야 한다.
flowchart LR
A[주문 확정 명령] --> B[재고 조건부 차감]
B --> C[주문 상태 변경]
C --> D[결제 원장 생성]
D --> E{모두 성공?}
E -->|예| F[COMMIT]
E -->|아니오| G[ROLLBACK]여기서 중요한 것은 “쿼리가 세 개니까 트랜잭션을 쓴다”가 아니다. 세 쓰기가 하나의 유스케이스에서 함께 지켜야 하는 상태이기 때문에 묶는다. 통계 조회 두 개를 동일 시점으로 읽으려는 경우처럼 읽기 일관성을 위해 트랜잭션을 사용할 수도 있지만, 단순히 쿼리가 여러 개라는 이유만으로 항상 묶을 필요는 없다.
Prisma가 제공하는 선택지를 한눈에 보기
Prisma Client를 사용하는 애플리케이션에서는 흔히 다음 선택지를 만난다.
| 방식 | 적합한 상황 | 중간 결과 사용 | 트랜잭션 길이 |
|---|---|---|---|
| nested write | 연관된 레코드를 한 명령으로 생성·수정 | Prisma 관계 표현 안에서 가능 | 가장 짧음 |
createMany, updateMany |
같은 형태의 대량 변경 | 제한적 | 짧음 |
$transaction([...]) |
서로 독립적인 여러 Prisma 작업 | 불가능하거나 사전 ID 필요 | 짧음 |
$transaction(async tx => ...) |
조회·분기·반복이 필요한 read-modify-write | 가능 | 코드에 따라 길어짐 |
| 원자적 연산·조건부 쓰기 | 카운터 증가, 상태 전이, 수량 차감 | 영향 행 수로 판단 | 매우 짧음 |
이 글의 코드는 $transaction API를 제공하는 Prisma Client 계열을 기준으로 한 재구성 예제다. Prisma의 새 메이저 버전에서는 API 표면이 달라질 수 있으므로 실제 프로젝트 버전의 공식 문서를 함께 확인해야 한다.
배열형 트랜잭션은 독립 작업에 적합하다
배열형 API는 PrismaPromise 목록을 받아 한 트랜잭션에서 순서대로 실행한다.
const [archivedSessions, auditLog] = await prisma.$transaction([
prisma.session.updateMany({
where: {
userId,
revokedAt: null,
},
data: {
revokedAt: now,
},
}),
prisma.securityAudit.create({
data: {
userId,
action: "REVOKE_ALL_SESSIONS",
occurredAt: now,
},
}),
]);
두 번째 쿼리는 첫 번째 결과가 몇 건인지에 따라 달라지지 않는다. 실행 전에 두 쿼리를 모두 구성할 수 있으므로 배열형이 잘 맞는다.
Prisma 쿼리가 반환하는 것은 일반 JavaScript Promise와 달리 실행이 지연되는 PrismaPromise다. 배열을 만들 때 바로 DB로 전송되지 않고 $transaction이 이를 묶어 실행할 수 있다.
const revokeOperation = prisma.session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: now },
});
const auditOperation = prisma.securityAudit.create({
data: { userId, action: "REVOKE_ALL_SESSIONS", occurredAt: now },
});
const result = await prisma.$transaction([
revokeOperation,
auditOperation,
]);
첫 번째 결과를 두 번째 입력으로 써야 한다면
자동 생성된 주문 ID를 다음 생성에 넣어야 하는 경우 배열형만으로는 표현하기 곤란하다.
// order.id는 첫 번째 쿼리가 실행된 뒤에야 알 수 있다.
const order = await prisma.order.create({ data: orderData });
await prisma.orderItem.create({
data: {
orderId: order.id,
productId,
quantity,
},
});
이 코드는 각각 await했으므로 하나의 트랜잭션이 아니다. 해결책은 세 가지다.
첫째, 애플리케이션에서 ID를 미리 생성하면 배열형을 유지할 수 있다.
const orderId = crypto.randomUUID();
await prisma.$transaction([
prisma.order.create({
data: { id: orderId, userId, status: "PENDING" },
}),
prisma.orderItem.create({
data: { orderId, productId, quantity },
}),
]);
둘째, 관계가 Prisma 스키마에 표현되어 있다면 nested write가 더 자연스럽다.
const order = await prisma.order.create({
data: {
userId,
status: "PENDING",
items: {
create: cartItems.map((item) => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
})),
},
},
select: {
id: true,
},
});
셋째, 중간 결과에 따른 로직이 더 많다면 interactive transaction을 선택한다.
모든 작업의 인자를 트랜잭션 시작 전에 만들 수 있고, 중간 결과에 따른 if나 계산이 없다면 배열형을 먼저 고려한다.
Interactive transaction은 읽고 판단한 뒤 쓸 때 사용한다
Interactive transaction은 callback 안에서 조회, 조건 분기, 계산, 쓰기를 수행한다. 콜백이 정상 반환하면 커밋되고 예외를 던지면 롤백된다.
가상의 포인트 송금 예제를 보자.
import { Prisma } from "@prisma/client";
type TransferPointCommand = {
fromUserId: string;
toUserId: string;
amount: number;
requestId: string;
};
async function transferPoint(command: TransferPointCommand): Promise<void> {
await prisma.$transaction(
async (tx) => {
const duplicated = await tx.pointTransfer.findUnique({
where: { requestId: command.requestId },
select: { id: true },
});
if (duplicated) {
return;
}
const source = await tx.pointWallet.findUniqueOrThrow({
where: { userId: command.fromUserId },
select: { balance: true },
});
if (source.balance < command.amount) {
throw new InsufficientPointError();
}
const debit = await tx.pointWallet.updateMany({
where: {
userId: command.fromUserId,
balance: { gte: command.amount },
},
data: {
balance: { decrement: command.amount },
},
});
if (debit.count !== 1) {
throw new InsufficientPointError();
}
await tx.pointWallet.update({
where: { userId: command.toUserId },
data: { balance: { increment: command.amount } },
});
await tx.pointTransfer.create({
data: {
requestId: command.requestId,
fromUserId: command.fromUserId,
toUserId: command.toUserId,
amount: command.amount,
},
});
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 2_000,
timeout: 5_000,
},
);
}
이 예제에는 배열형으로 표현하기 어려운 의존성이 있다.
- 기존
requestId를 조회해 멱등 처리한다. - 현재 잔액을 읽고 부족하면 중단한다.
- 조건부 차감 결과를 확인한다.
- 어느 단계든 실패하면 입금과 원장 기록까지 롤백해야 한다.
반드시 tx를 사용한다
콜백 안에서 실수로 전역 prisma를 사용하면 해당 쿼리는 열린 트랜잭션 밖에서 실행될 수 있다.
await prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id: orderId },
data: { status: "CONFIRMED" },
});
// 잘못된 예: 전역 client를 사용한다.
await prisma.orderAudit.create({
data: { orderId, action: "CONFIRMED" },
});
throw new Error("force rollback");
});
주문 상태는 롤백되는데 감사 로그는 커밋되는 이상한 결과가 생길 수 있다. 저장소를 주입하는 구조라면 transaction client가 모든 repository로 전파되는지도 확인해야 한다.
type DbClient = PrismaClient | Prisma.TransactionClient;
class OrderRepository {
constructor(private readonly db: DbClient) {}
withClient(db: DbClient): OrderRepository {
return new OrderRepository(db);
}
}
트랜잭션 콜백 안에서 피해야 할 작업
Interactive transaction은 표현력이 높은 대신, callback이 오래 실행될수록 DB 연결과 잠금을 오래 점유한다.
다음은 위험한 패턴이다.
await prisma.$transaction(async (tx) => {
const order = await tx.order.findUniqueOrThrow({
where: { id: orderId },
});
// 외부 결제사가 8초 동안 응답하지 않으면 트랜잭션도 열린 채로 기다린다.
const payment = await paymentGateway.capture({
amount: order.totalAmount,
});
await tx.order.update({
where: { id: order.id },
data: {
status: "PAID",
paymentKey: payment.key,
},
});
});
외부 HTTP 호출, 사용자 입력 대기, 큰 파일 처리, 긴 CPU 계산은 callback 밖으로 이동한다. 결제처럼 외부 시스템과 DB를 하나의 ACID 트랜잭션에 묶을 수 없다면 상태 머신과 멱등성, 보상 작업을 설계해야 한다.
sequenceDiagram
participant API
participant DB
participant Pay as 결제사
API->>DB: 결제 시도 상태 생성(COMMIT)
API->>Pay: 멱등성 키로 승인 요청
Pay-->>API: 승인 결과
API->>DB: 짧은 트랜잭션으로 결과 반영
Note over API,DB: 중간 실패는 재조회·재시도로 복구예를 들어 먼저 결제 시도 레코드를 만든 후 트랜잭션을 끝내고, 외부 결제를 호출한 뒤 별도의 짧은 트랜잭션에서 주문 상태를 바꾼다. 네트워크 타임아웃으로 결과가 불명확하면 동일한 멱등성 키로 결제 상태를 확인한다.
Prisma의 interactive transaction timeout은 트랜잭션 실행 시간을 제한한다. 외부 API의 취소와 같은 의미는 아니며, 트랜잭션 안에 네트워크 호출을 넣어도 안전해지는 것은 아니다.
격리 수준은 동시성 문제의 종류에 맞춘다
트랜잭션을 사용했다고 모든 경쟁 상태가 자동으로 사라지지는 않는다. 격리 수준은 동시에 실행되는 트랜잭션이 서로의 변경을 얼마나 강하게 분리할지 정한다.
| 격리 수준 | 개념적 특징 | 비용과 주의점 |
|---|---|---|
| Read Committed | 커밋된 데이터만 읽음 | 같은 행을 다시 읽으면 값이 달라질 수 있음 |
| Repeatable Read | 읽은 행의 일관성을 더 강하게 유지 | DB별 동작 차이, 충돌·잠금 고려 |
| Serializable | 직렬 실행과 같은 결과를 목표로 함 | 충돌로 트랜잭션이 실패할 수 있어 재시도 필요 |
지원되는 격리 수준과 기본값은 PostgreSQL, MySQL, SQL Server 등 커넥터마다 다르다. 명시하지 않으면 일반적으로 데이터베이스 설정을 따른다.
await prisma.$transaction(
async (tx) => {
await confirmOrder(tx, orderId);
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
);
무조건 Serializable을 붙이는 것보다 실제 불변식을 먼저 적어 보는 편이 낫다.
- 재고가 음수가 되면 안 된다 →
quantity >= requested조건부 UPDATE - 사용자당 요청 키가 하나여야 한다 → UNIQUE 제약
- 두 행을 읽어 계산한 합계가 상한을 넘으면 안 된다 → 강한 격리나 명시적 잠금 검토
단일 SQL과 제약으로 표현할 수 있는 규칙은 그쪽이 더 단순하고 충돌 범위도 좁다.
Serializable 충돌은 정상적인 실패 경로다
Serializable은 경쟁을 없애는 것이 아니라 안전하지 않은 실행 중 하나를 실패시킬 수 있다. 따라서 재시도 가능한 오류를 제한된 횟수만큼 다시 실행하는 전략이 필요하다.
async function runSerializable<T>(
work: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await prisma.$transaction(work, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
maxWait: 1_500,
timeout: 4_000,
});
} catch (error) {
const retryable = isSerializationConflict(error);
if (!retryable || attempt === maxAttempts) {
throw error;
}
await delay(withJitter(25 * 2 ** (attempt - 1)));
}
}
throw new Error("unreachable");
}
재시도 callback 안에는 외부 부수 효과가 없어야 한다. 이메일 발송이나 결제 승인을 넣으면 DB 트랜잭션은 롤백되어도 외부 작업은 중복 실행될 수 있다.
또한 입력 명령 자체에 멱등성 키를 두면 클라이언트 재시도까지 안전하게 처리할 수 있다.
model PointTransfer {
id String @id @default(cuid())
requestId String @unique
fromUserId String
toUserId String
amount Int
}
모든 DB 오류를 재시도하지 않는다. UNIQUE 충돌처럼 확정적인 업무 실패와 serialization conflict처럼 실행 순서 때문에 생긴 일시적 실패를 분류해야 한다.
트랜잭션을 쓰지 않고도 원자성을 얻는 방법
읽고 수정하는 코드를 트랜잭션으로 감싸기 전에 한 SQL로 바꿀 수 있는지 살펴본다.
원자적 숫자 연산
await prisma.article.update({
where: { id: articleId },
data: {
viewCount: { increment: 1 },
},
});
findUnique로 값을 읽고 viewCount + 1을 저장하면 두 요청의 증가분 하나가 사라질 수 있다. 원자적 increment는 읽기와 쓰기의 틈을 없앤다.
조건부 상태 전이
const changed = await prisma.order.updateMany({
where: {
id: orderId,
status: "PENDING",
},
data: {
status: "CONFIRMED",
confirmedAt: now,
},
});
if (changed.count !== 1) {
throw new InvalidOrderStateError();
}
현재 상태를 조회하고 나서 업데이트하기보다, 기대 상태를 UPDATE 조건에 포함한다. 이 방식은 낙관적 동시성 제어의 가장 단순한 형태다.
버전 열을 사용한 낙관적 동시성 제어
const result = await prisma.document.updateMany({
where: {
id: command.id,
version: command.expectedVersion,
},
data: {
content: command.content,
version: { increment: 1 },
},
});
if (result.count === 0) {
throw new ConcurrentModificationError();
}
사용자가 편집을 시작한 뒤 다른 사람이 먼저 저장했다면 조용히 덮어쓰지 않고 충돌로 알린다. 여러 테이블을 함께 바꾸지 않는다면 별도의 interactive transaction 없이도 충분할 수 있다.
테스트와 관측 가능성
트랜잭션 테스트는 “예외가 발생했다”보다 최종 상태를 검증해야 한다.
it("원장 생성 실패 시 재고와 주문 상태를 롤백한다", async () => {
ledgerRepository.failNextInsert();
await expect(
orderService.confirm({ orderId: "order_test_1" }),
).rejects.toThrow();
const order = await prisma.order.findUniqueOrThrow({
where: { id: "order_test_1" },
});
const stock = await prisma.stock.findUniqueOrThrow({
where: { productId: "product_test_1" },
});
expect(order.status).toBe("PENDING");
expect(stock.quantity).toBe(10);
});
경쟁 상태는 실제 DB에 동시 요청을 보내 확인한다.
const results = await Promise.allSettled(
Array.from({ length: 20 }, (_, index) =>
transferPoint({
fromUserId: "sender",
toUserId: `receiver_${index}`,
amount: 10,
requestId: `request_${index}`,
}),
),
);
expect(await readTotalBalance()).toBe(initialTotalBalance);
expect(await readBalance("sender")).toBeGreaterThanOrEqual(0);
운영에서는 다음을 관측한다.
- 트랜잭션 실행 시간의 p95·p99
maxWait초과와 pool 대기 시간- serialization conflict와 재시도 횟수
- deadlock·lock timeout 발생 수
- 트랜잭션별 쿼리 수
트랜잭션 이름이나 유스케이스를 로그 속성으로 남기되 SQL 파라미터에 개인정보가 포함되지 않도록 주의한다.
logger.info({
event: "transaction.completed",
usecase: "point_transfer",
durationMs,
retryCount,
result: "success",
});
선택 체크리스트
아래 순서로 질문하면 불필요하게 긴 interactive transaction을 줄일 수 있다.
- 한 번의
create, nested write,createMany로 표현할 수 있는가? - 원자적
increment, 조건부updateMany, UNIQUE 제약으로 해결할 수 있는가? - 여러 작업이 서로 결과에 의존하지 않는가? 그렇다면 배열형을 고려한다.
- 중간 조회와 조건 분기가 필요한가? 그렇다면 interactive transaction을 고려한다.
- callback 안에 외부 API나 긴 계산이 있는가? 트랜잭션 밖으로 이동한다.
- 격리 수준에서 충돌할 수 있는가? 재시도 정책과 멱등성을 설계한다.
- 모든 repository가 같은
txclient를 사용하는가?
정리
Prisma의 두 $transaction 형태는 우열 관계가 아니라 의존성의 차이를 표현한다. 실행 전 모든 작업을 구성할 수 있으면 배열형이 간결하고 트랜잭션도 짧아진다. 조회 결과에 따라 다음 쿼리가 달라지면 interactive transaction이 필요하다.
그러나 가장 좋은 트랜잭션은 종종 더 작은 쿼리다. nested write, 원자적 연산, 조건부 UPDATE, UNIQUE 제약으로 불변식을 표현하면 애플리케이션 callback의 길이와 동시성 위험을 함께 줄일 수 있다.
트랜잭션 API를 먼저 고르지 말고 지켜야 할 불변식과 쿼리 간 의존성을 먼저 적는다. 그 결과가 독립 작업이면 배열형, read-modify-write라면 interactive transaction을 선택한다.